Skip to content
🌊海洋蓝
🌸樱花粉
🍃森林绿
🔮幻夜紫
🌙暗夜黑

概述

组件复用是指自定义组件从组件树上移除后被放入缓存池,后续在创建相同类型的组件节点时,直接复用缓存池中的组件对象。

在应用开发时,组件复用是优化 UI 性能,确保应用流畅的重要手段。合理使用可复用组件,一方面,可以避免频繁创建和销毁对象的过程,减少内存回收的频率;另一方面,复用缓存中的组件可以直接绑定数据进行显示,与创建新视图相比,降低了计算开销,提升了显示效率。

常见的组件复用开发场景是长列表滑动:在应用展示大量数据的列表界面中,当用户快速地进行滑动操作,列表项反复创建销毁可能导致卡顿等性能问题。这种情况下,使用组件复用机制可以重用已经创建过的列表项视图,提高滑动的流畅度。

本文介绍以下组件复用开发场景,帮助开发者更好地理解复用机制,进而优化应用性能。

说明

本文以列表相关场景为例介绍,但实际只要发生了自定义组件的销毁和再创建,都可以考虑使用组件复用。包括以下情形:

  1. 滑动场景下对子组件进行频繁创建和销毁。例如 List、Grid、WaterFlow、Swiper 等布局容器中的滑动。
  2. 界面中反复切换条件渲染的控制分支,且控制分支中的子组件树结构比较复杂。

场景:同一列表内的组件复用

场景描述

同一列表内的列表项组件复用是典型的应用开发场景。列表在滑动时,超出屏幕一定范围的列表项,被放入缓存池中,当新的列表项滑动进入屏幕范围内时,从缓存池中取出对象,绑定对应数据后呈现到列表界面中。

在实际业务中,同一列表内可能呈现一种或多种不同结构的列表项,可以进一步划分以下子场景:

  1. 标记了@Reusable 的自定义组件 listItem 列表项,在滑动出屏幕一定范围后,从组件树上被移除,组件的对象实例被放入 CustomNode 虚拟结点(与自定义组件一一对应的自定义结点)。
  2. 在不断滑动过程中,列表的 RecycleManager 将这些 CustomNode 虚拟结点回收,根据复用标识reuseId分组,形成 CachedRecycleNodes 的集合,即视图对象的复用缓存池。
  3. 继续滑动,新的 listItem 需要在列表上显示时,RecycleManager 优先从复用缓存池(CachedRecycleNodes 集合)中查找对应 reuseId 的视图对象,然后将新的数据绑定到该视图,重用该节点并添加到组件树上。

开发流程

  1. 定义可复用组件:使用@Reusable 装饰器修饰可复用的自定义组件。
  2. 实现复用回调:可复用组件需要实现 aboutToReuse()生命周期回调。当组件从缓存中重新加入到节点树时,触发 aboutToReuse()生命周期回调,组件的构造参数会传递进来,开发者根据需要在回调中处理数据刷新。
  3. 布局中使用可复用组件:设置 reuseId 划分组件的复用组别,以区分缓存池。未设置 reuseId 时,组件名会默认作为 reuseId。

注意

  • @Reusable 修饰的组件需要布局在同一个父自定义组件(后文简称”父组件”)下才能实现缓存复用。
  • 不建议在@Reusable 修饰的组件中嵌套使用另一个@Reusable 组件。

更多注意事项参考指南限制条件

列表项结构类型相同

这种场景下,列表中的每一项都是由相同类型的元素和布局构成,列表项组件可以作为复用逻辑的基本单位。

实现步骤:

  1. 将列表项封装为自定义组件 ItemView,添加@Reusable 修饰。
  2. 在 ItemView 组件内的 aboutToReuse()方法中进行新数据绑定逻辑。
  3. 在列表的 LazyForEach 中使用 ItemView 组件,设置 reuseId。

列表项结构类型不同

这种场景下,列表中会有多种类型的列表项,如下图包含了文本、单图、多图等三种列表项,其布局、组成元素存在一定差异,可以将每种类型的列表项分别作为复用单位。

在滑动的过程中,不同类型的列表项将分别回收进入各自的缓存池,当需要复用时,根据类型找到对应视图缓存进行显示。

实现步骤:

  1. 将不同类型的列表项分别封装为自定义组件,添加@Reusable 修饰。
  2. 在组件内的 aboutToReuse()方法中进行新的数据绑定逻辑。
  3. 在列表的 LazyForEach 中,根据业务逻辑进行 if 条件选择,布局相应类型的列表项组件,分别设置 reuseId。

列表项内子组件可拆分组合

这种情况下,列表项也具有多种结构类型。通过观察可知,列表项内部子组件都是纵向分布排列,相同之处是顶部的文本标题、底部的发布时间,而不同之处是中间的区域部分:有单图、多图、视频三种情况。

因此,可以创建五种复用子组件,通过子组件的选择组合,实现不同类型的列表项。

实现步骤:

  1. 将单图、多图、视频、顶部标题、底部时间等分别封装为子组件,添加@Reusable 修饰。
  2. 在组件内的 aboutToReuse()方法中进行新的数据绑定逻辑。
  3. 通过组合子组件,实现三个不同的@Builder 函数,与三种列表项一一对应。
  4. 在列表的 LazyForEach 中,根据业务逻辑进行条件选择,分别调用相应类型的@Builder 函数。

说明

为什么使用@Builder 实现,而不直接使用自定义组件嵌套子组件?

由于缓存池位于自定义组件上,嵌套子组件后会将缓存池分割,导致复用不生效。而使用@Builder 可以使内部的自定义组件依然汇聚在同一个缓存池里,从而实现相互复用。

示例代码如下:

场景:多个列表间的组件复用

场景描述

应用开发有这种场景:在不同的标题页面中展示数据,每一页面下实现了一个列表,这样在页面切换时,列表与列表之间如果存在结构相同的列表项,就有组件复用的优化可能。例如下图,News、Hot 等页签下,绘制了类型相同的列表项。

实现原理

在 ArkUI 中,可以采用 Swiper+List 实现这种功能场景,其中 Swiper 中的每个页面都使用一个 List 列表呈现内容。从@Reusable 的复用机制可知,复用缓存池需要在同一父组件中,而列表项 Item 的父组件是当前页面的列表 List,当 Swiper 内的页面切换时,无法直接复用上一个页面的列表项。

此时可以自定义一个全局的复用缓存池 NodePool,利用BuilderNode的节点复用能力,根据页面状态创建、回收、复用子组件,实现这种跨页面多个列表间的组件复用。

说明

为什么不使用 Tabs+List,而是用 Swiper+List 组件实现?

当前 Tabs 内容页不支持使用 LazyForEach,只能使用 ForEach+TabContent。如果使用 ForEach,Tabs 页面显示时会一次性将所有的 TabContent 创建,TabContent 子页面切换时也不会执行 aboutToDisappear(),无法回收组件,进而不存在复用优化的可能 。

在需要布局自定义组件的位置,使用NodeContainer占位,然后继承NodeController实现 NodeItem 结点类,其内部需要持有BuilderNode实例以实现结点的创建和复用,同时需要持有视图相应的数据对象以更新界面显示。

  1. 当 NodeItem 随着视图组件即将销毁时,在 aboutToDisappear()中回收 NodeItem 到 NodePool 缓存池,存入 type 类型对应的集合中。
  2. 每次需要创建自定义组件时,优先根据 type 类型查找对应的 NodeItem 对象,若未找到则新建一个 NodeItem。
  3. 视图组件随着 NodeContainer 的生命周期显示时,执行数据更新,完成组件的复用过程。

这种方式需要自行维护复用池,开发者也可以考虑使用同一原理实现的全局组件复用池三方库:nodepool

开发步骤

  1. 实现列表项占位结点类 NodeItem,继承 NodeController 实现 makeNode()方法,根据 node 是否存在,执行创建或刷新数据的逻辑,并在 aboutToDisappear()时回收组件结点。

  2. 使用单例模式实现复用缓存池 NodePool 工具类,在应用内统一管理组件的复用逻辑:实现取缓存 getNode()方法,根据传入的 type 类型,获取对应的 NodeItem,如果未找到,则新创建后绑定数据;实现缓存回收 recycleNode()方法,根据 type 类型存入相应的集合中。

    注意

    • getNode()方法中,如果找到的 NodeItem 父结点不为空(说明未完全下树),需要继续遍历查找下一个有效的 NodeItem 对象。
    • recycleNode()方法中,需要对 NodeItem 对象属性重置,使节点内容还原,避免复用显示异常情况。
  3. 将步骤 1 中的列表项占位结点包装成组件,在对应的生命周期中分别取缓存、回收、复用。

  4. 封装列表项的界面视图组件,使用 listItemBuilder 函数对外 export 该组件。

  5. 在列表的 LazyForEach 中,将步骤 4 的实际列表项视图 wrapBuilder 后作为参数传递给步骤 3 封装的占位组件,实现复用组件的布局。

使用 onIdle()预创建组件

在当前场景下,首次进入页面可能耗时较高,因为在第一次进入时,自定义组件复用池中没有缓存可以复用,列表项都需要新创建。优化这个问题,可以考虑预创建组件,将组件对象提前放入复用缓存池中。

当组件数量较多,集中预创建本身也耗时较长,容易导致主线程阻塞。ArkUI 中提供了onIdle 接口,会返回每一帧帧尾的空闲时间,可以将组件预创建分布到每一帧帧尾的空闲时间中执行,这样预创建过程就被平摊在多个周期里执行,避免集中运行的耗时影响,进而优化应用体验。

注意

1. 需要根据业务准确预估组件预创建耗时,同时将业务逻辑颗粒度拆小,以便能够分到多个 onIdle 时机中完成。例如,单个组件预创建耗时在 2ms 左右,帧尾空闲时间只有 1ms,那么就不能在当前帧进行预创建,而是延迟到下一帧中执行。

2. 需要合理控制自定义组件复用池中预创建的数量,否则内存占用较多,可能会影响性能。

  1. 在 NodePool 工具类中实现预创建 preBuild()方法:新建 NodeItem 实例,设置 builder 等属性,执行 recycleNode()提前放入缓存池中。
  2. 继承 FrameCallback 实现帧回调类,在构造器中传入预创建的数据,并实现 onIdle 接口。
    1. 系统会通过 onIdle()回调,将帧尾空闲时间通过参数 idleTimeInNano 传递出来,可根据单个组件的预创建耗时,设置预创建的剩余空闲时间上限(示例代码假设单个组件预创建耗时最长 1ms=1000000ns)。
    2. 当剩余空闲时间足够创建组件时,在这一帧中进行组件预创建,并不断更新当前帧的剩余空闲时间。
    3. 若当前帧剩余空闲时间不足以创建组件,通过 postFrameCallback()方法,将回调传递到下一帧,继续进行剩余组件的预创建。
  3. 在进入 Swiper+List 的页面之前,选择合适的时机执行 context.postFrameCallback(),开启 IdleCallback 帧回调逻辑。

更多优化方法

使用 attributeUpdater 实现组件属性的部分刷新

在可复用组件中使用attributeUpdater可以控制指定属性的刷新,避免不必要的重绘、减少渲染负载,从而提升应用性能。

默认情况下,直接使用状态变量在 aboutToReuse()中进行数据赋值,会导致组件全部属性刷新。实际需求中,可能只需要更新组件的部分属性。例如,当组件复用显示时,只希望设置文本的字体颜色,那么可以使用 attributeUpdater 在 aboutToReuse 修改 fontColor 属性。

反例:

这里通过 aboutToReuse()对 fontColor 状态变量更新,导致组件全部属性进行刷新,造成不必要的耗时。

正例:

通过 attributeUpdater 对 Text 组件中的 fontColor 属性进行精准刷新,避免重绘 Text 中不需要更改的属性。

在可复用组件中,建议使用@Link/@ObjectLink替代@Prop。因为@Prop 装饰变量时会进行深拷贝,增加了创建时间及内存消耗,而改用@Link/@ObjectLink,变量会共享同一地址。

反例:

正例:

将子组件 moment 变量@Prop 改为@ObjectLink 即可。

父子组件之间的数据同步用了@ObjectLink 来进行,子组件@ObjectLink 包装类把当前 this 指针注册给父组件,会直接将父组件的数据同步给子组件,实现父子组件数据的双向同步,降低子组件创建时间和内存消耗。

如果可复用组件中使用了@Link/@ObjectLink/@Prop等自动同步父子组件数据的状态变量,则不需要在 aboutToReuse()中对这些数据重复赋值。如果重新赋值这些变量,会导致组件的内容重新触发状态刷新,造成额外的计算更新耗时。

反例:

正例:

将 aboutToReuse()中的赋值语句删除。

@ObjectLink 修饰的 moment 状态变量已包含自动刷新功能,不需要再重复赋值刷新。

使用 reuseId 标记布局发生变化的组件

在同一段自定义组件代码中,如果使用 if/else 条件语句控制布局结构,会导致在不同逻辑分支中创建不同布局的组件,从而造成组件树结构的差异。此时可以使用 reuseId 来区分发生变化的分支逻辑,确保系统能够根据 reuseId 缓存各种结构的组件,提升复用性能。

反例:

组件通过 if 条件创建包含 Image 的 Flex 组件。不使用 reuseId 时,复用后根据 if 条件,可能会删除 Flex 或重新创建 Flex,存在性能消耗。

正例:

根据分支逻辑设置不同的 reuseId,缓存不同布局结构下的组件,省去重复执行 if 的删除或创建逻辑。

避免使用函数方法作为复用组件的入参

如果可复用组件的入参使用了函数方法,因每次复用都需要重新创建组件关联的数据对象,该函数会在每次复用时执行,造成性能问题。建议改为通过状态变量传递参数,从而减少重复执行入参中的函数所带来的性能消耗。

反例:

复用的子组件参数 sum 是通过模拟耗时函数 countAndReturn()生成。该函数在每次组件复用时都执行,会造成性能问题,甚至导致列表滑动过程中的卡顿丢帧。

正例:

可以先将 countAndReturn()计算放到页面初始时执行,将结果赋值给 this.sum 变量。在复用组件的参数传递时,通过 this.sum 来进行。

常见问题

如何检查组件复用是否生效

Released under the MIT License.